Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

348 linesCodeBlameRaw
1import * as Clipboard from "expo-clipboard";
2import { Stack, router, useLocalSearchParams } from "expo-router";
3import { useHeaderHeight } from "expo-router/react-navigation";
4import * as WebBrowser from "expo-web-browser";
5import {
6 CircleCheck,
7 CircleDot,
8 CircleX,
9 Copy,
10 ExternalLink,
11 FolderGit2,
12 Link2,
13 Plus,
14 Search,
15 Unlink,
16} from "lucide-react-native";
17import { useState } from "react";
18import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native";
19
20import { Sheet, SheetAction } from "@/components/chat/sheet";
21import { Body, Checks, PullIcon, Timeline } from "@/components/code";
22import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
23import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work";
24import { useRepos } from "@/lib/code";
25import { siteUrl } from "@/lib/server";
26import { useSignedIn } from "@/lib/session";
27import { ago } from "@/lib/time";
28import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work";
29import { radius, space, useColors } from "@/theme";
30
31/**
32 * A work item: what it asks for, the repositories it is linked to (where
33 * it came from, where it is meant to land, and what has landed where),
34 * each one's pull requests for it, and its conversation. The bar at the
35 * foot comments, and its ⋯ closes, reopens and links a repository.
36 */
37export default function WorkItem() {
38 const params = useLocalSearchParams<{ number: string; workspace?: string }>();
39 const { server, workspace: current } = useSignedIn();
40 const workspace = params.workspace ?? current?.slug ?? "";
41 const number = Number(params.number);
42 const detail = useWorkItem(workspace, number);
43 const context = useWorkContext(workspace, number);
44 const actions = useItemActions(workspace, number);
45 const headerHeight = useHeaderHeight();
46 const colors = useColors();
47 const [more, setMore] = useState(false);
48 const [linking, setLinking] = useState(false);
49 const [picked, setPicked] = useState<ApiItemRepo | null>(null);
50
51 const title = <Stack.Screen options={{ title: `#${number}` }} />;
52 if (detail.isPending || detail.error) {
53 return (
54 <Screen>
55 {title}
56 {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />}
57 </Screen>
58 );
59 }
60
61 const { item, comments } = detail.data;
62 const by = item.author.display_username ?? item.author.username;
63 const refs = { workspace };
64 const sitePath = `/${workspace}/-/work/${number}`;
65 // The context has each repository's pull requests; the item alone still has its repositories.
66 const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] }));
67 const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path);
68 const linked = new Set(repos.map((repo) => repo.path.toLowerCase()));
69 const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase()));
70
71 const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong.");
72 const close = (reason: "completed" | "not_planned") => {
73 setMore(false);
74 actions.close.mutate(reason, { onError: failed("Could not close it") });
75 };
76 const reopen = () => {
77 setMore(false);
78 actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") });
79 };
80 const link = (repo: string) => {
81 setLinking(false);
82 actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") });
83 };
84 const openRepo = (path: string) => {
85 const [owner, name] = path.split("/", 2);
86 router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } });
87 };
88 const openPull = (pull: ApiWorkPull) => {
89 const [owner, name] = pull.repo.split("/", 2);
90 router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } });
91 };
92 const busy = actions.close.isPending || actions.reopen.isPending;
93
94 return (
95 <Screen>
96 {title}
97 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
98 <ScrollView
99 contentContainerStyle={styles.content}
100 keyboardShouldPersistTaps="handled"
101 keyboardDismissMode="interactive"
102 refreshControl={
103 <RefreshControl
104 refreshing={detail.isRefetching}
105 onRefresh={() => Promise.all([detail.refetch(), context.refetch()])}
106 tintColor={colors.accent}
107 />
108 }
109 >
110 <View style={{ gap: space.sm }}>
111 <Text size="heading" selectable>
112 {item.title}
113 </Text>
114 <View style={styles.wrap}>
115 <ItemIcon item={item} size={18} />
116 <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}>
117 {stateLabel(item)}
118 </Text>
119 <Text size="small" tone="muted">
120 #{item.number} · {by} · {ago(item.created_at)}
121 </Text>
122 </View>
123 {item.labels.length || item.milestone || item.assignees.length ? (
124 <View style={styles.wrap}>
125 {item.labels.map((label) => (
126 <Chip key={label} label={label} />
127 ))}
128 {item.milestone ? <Chip label={item.milestone.title} /> : null}
129 {item.assignees.map((name) => (
130 <Chip key={name} label={`@${name}`} />
131 ))}
132 </View>
133 ) : null}
134 </View>
135
136 <Body text={item.body} refs={refs} />
137
138 <View style={{ marginHorizontal: -space.lg }}>
139 <SectionHeader title="Repositories" />
140 {repos.length === 0 ? (
141 <Text tone="muted" style={{ paddingHorizontal: space.lg }}>
142 Not linked to a repository yet. Link the ones where the work is meant to land.
143 </Text>
144 ) : (
145 repos.map((repo) => (
146 <View key={repo.repo_id}>
147 <Row
148 leading={<FolderGit2 size={20} color={colors.muted} />}
149 title={repoName(repo.path, workspace)}
150 subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`}
151 trailing={
152 repo.landed ? (
153 <Badge label="Landed" tone="merged" />
154 ) : repo.role === "target" ? (
155 <Badge label="Not landed" tone="warn" />
156 ) : null
157 }
158 onPress={() => setPicked(repo)}
159 />
160 {repo.pulls.map((pull) => (
161 <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}>
162 <Row
163 leading={<PullIcon pull={pull} />}
164 title={pull.title}
165 subtitle={`#${pull.number} · ${pull.status}`}
166 trailing={<Checks status={pull.check_status} />}
167 onPress={() => openPull(pull)}
168 />
169 </View>
170 ))}
171 </View>
172 ))
173 )}
174 {missing.length && item.state === "open" ? (
175 <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}>
176 Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}.
177 </Text>
178 ) : null}
179 <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}>
180 <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} />
181 </View>
182 </View>
183
184 {suggested.length ? (
185 <View style={{ marginHorizontal: -space.lg }}>
186 <SectionHeader title="Suggested" />
187 {suggested.map((one) => (
188 <Row
189 key={one.repo_id}
190 leading={<FolderGit2 size={20} color={colors.faint} />}
191 title={repoName(one.path, workspace)}
192 subtitle={one.why}
193 chevron={false}
194 trailing={
195 <Button
196 title="Add as target"
197 variant="secondary"
198 icon={Plus}
199 busy={actions.link.isPending && actions.link.variables?.repo === one.path}
200 onPress={() => link(one.path)}
201 />
202 }
203 />
204 ))}
205 </View>
206 ) : null}
207
208 <Timeline comments={comments} refs={refs} />
209
210 {item.state === "open" ? (
211 <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} />
212 ) : (
213 <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} />
214 )}
215 </ScrollView>
216 <CommentBar
217 busy={actions.comment.isPending}
218 onMore={() => setMore(true)}
219 onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))}
220 />
221 </KeyboardAvoidingView>
222
223 <Sheet visible={more} onClose={() => setMore(false)}>
224 {item.state === "open" ? (
225 <>
226 <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} />
227 <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} />
228 </>
229 ) : (
230 <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} />
231 )}
232 <SheetAction
233 icon={Link2}
234 label="Link a repository"
235 onPress={() => {
236 setMore(false);
237 setLinking(true);
238 }}
239 />
240 <SheetAction
241 icon={Copy}
242 label="Copy link"
243 onPress={() => {
244 setMore(false);
245 void Clipboard.setStringAsync(siteUrl(server, sitePath));
246 }}
247 />
248 <SheetAction
249 icon={ExternalLink}
250 label="Open on g1t"
251 onPress={() => {
252 setMore(false);
253 void WebBrowser.openBrowserAsync(siteUrl(server, sitePath));
254 }}
255 />
256 </Sheet>
257
258 <Sheet visible={picked !== null} onClose={() => setPicked(null)}>
259 {picked ? (
260 <>
261 <Text size="heading" style={{ paddingHorizontal: space.lg }}>
262 {picked.path}
263 </Text>
264 <SheetAction
265 icon={FolderGit2}
266 label="Open the repository"
267 onPress={() => {
268 setPicked(null);
269 openRepo(picked.path);
270 }}
271 />
272 {picked.role !== "origin" ? (
273 <SheetAction
274 icon={Unlink}
275 label="Unlink it"
276 danger
277 onPress={() => {
278 setPicked(null);
279 actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") });
280 }}
281 />
282 ) : null}
283 </>
284 ) : null}
285 </Sheet>
286
287 <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} />
288 </Screen>
289 );
290}
291
292/** Picks a repository of the workspace to link as a target. */
293function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) {
294 const colors = useColors();
295 const { height } = useWindowDimensions();
296 const [search, setSearch] = useState("");
297 const repos = useRepos(visible ? search.trim() : "");
298 const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase()));
299 return (
300 <Sheet visible={visible} onClose={onClose}>
301 <View style={{ paddingHorizontal: space.lg, gap: space.sm }}>
302 <Text size="heading">Link a repository</Text>
303 <Text size="small" tone="muted">
304 As a target: where the work is meant to land. It closes once it has landed in every target.
305 </Text>
306 <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}>
307 <Search size={16} color={colors.faint} />
308 <TextInput
309 value={search}
310 onChangeText={setSearch}
311 placeholder="Find a repository"
312 placeholderTextColor={colors.faint}
313 autoCapitalize="none"
314 autoCorrect={false}
315 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }}
316 />
317 </View>
318 </View>
319 <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled">
320 {repos.isPending ? (
321 <Loading />
322 ) : choices.length === 0 ? (
323 <Text tone="muted" style={{ padding: space.lg }}>
324 {search ? "Nothing matches." : "Every repository you can see is linked already."}
325 </Text>
326 ) : (
327 choices.map((repo) => (
328 <Row
329 key={repo.id}
330 leading={<FolderGit2 size={20} color={colors.muted} />}
331 title={repo.name}
332 subtitle={`${repo.namespace}/${repo.name}`}
333 chevron={false}
334 trailing={<Plus size={18} color={colors.accent} />}
335 onPress={() => onPick(`${repo.namespace}/${repo.name}`)}
336 />
337 ))
338 )}
339 </ScrollView>
340 </Sheet>
341 );
342}
343
344const styles = StyleSheet.create({
345 content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl },
346 wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
347 search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md },
348});